← Writeups

Username Enumeration via Account Lock — PortSwigger Lab

Enumeración de usuarios forzando el bloqueo de cuentas con 5 intentos fallidos: la respuesta de bloqueo solo aparece para cuentas existentes.

Username Enumeration via Account Lock

1. Target Overview & Vulnerability Summary

Campo Valor
Severidad Media
Endpoint vulnerable POST /login
Root cause La política de bloqueo de cuenta (account lockout) se dispara solo para usuarios existentes, revelando qué cuentas son válidas mediante la respuesta de bloqueo

La app bloquea temporalmente una cuenta tras 5 intentos de login fallidos. El mensaje de bloqueo solo se emite para cuentas que existen: un usuario inexistente nunca llega a bloquearse. Esta asimetría convierte la política de bloqueo en un oráculo de enumeración de usuarios.

2. Technical Breakdown & Exploitation Flow

2.1 Forzar el bloqueo por cuenta

Para cada candidato de la wordlist se envían 5 requests de login con contraseña inválida:

POST /login HTTP/2
Content-Type: application/x-www-form-urlencoded

username=<CANDIDATO>&password=invalid

Repetir 5 veces por candidato. Las cuentas existentes alcanzan el umbral de bloqueo y devuelven el mensaje de lockout; las inexistentes responden siempre con el error de credenciales genérico.

2.2 Detección de cuentas válidas

Tras la quinta request, la respuesta distingue ambos casos:

You have made too many incorrect login attempts. Please try again in 1 minute.   # Usuario existe
Invalid username or password                                                      # Usuario no existe

Los candidatos que devuelven el mensaje de bloqueo son usuarios válidos. Con el usuario confirmado, el siguiente paso es esperar la ventana de desbloqueo (~1 minuto) y ejecutar la fuerza bruta de la contraseña.

3. Root Cause Analysis

El fallo es aplicar la política de bloqueo después de la comprobación de existencia del usuario:

# Implementación vulnerable
user = db.get_user(request.username)
if user is None:
    return "Invalid username or password"     # ❌ No cuenta como intento

increment_failed_attempts(user)               # Solo para usuarios existentes
if failed_attempts(user) >= 5:
    lock_account(user)                        # El bloqueo revela la existencia

La política de account lockout debe contar intentos de forma indistinguible entre cuentas válidas e inválidas; si no, la propia defensa expone el inventario de usuarios (además de permitir un DoS por bloqueo masivo de cuentas reales).

4. Secure Remediation

Contar los intentos por username+IP y devolver la misma respuesta en todos los casos:

# Implementación segura
attempts = get_attempts(request.username, request.ip)
increment_attempts(request.username, request.ip)

if attempts >= 5:
    return generic_lockout_message()          # Mismo mensaje para usuarios reales y ficticios

user = db.get_user(request.username)
if user is None or not valid_credentials(user, request.password):
    return "Invalid username or password"

El mensaje de bloqueo debe ser idéntico para todos los usernames (reales o no) y, cuando aplique desbloqueo temporal, la ventana debe contar también para los inexistentes para no diferenciarlos.